React Hook Formで動的な入力フォームを作ってみた
入力フォームをユーザー操作などにより追加したり削除したり並び順を変えたりする動的な入力フォームを作ってみました。
useFieldArrayとは
Reactアプリケーションで入力フォームを作成し管理するためのライブラリとして人気なReact Hook Formの機能の一つです。
useFieldArrayを利用することで動的な入力フォームを簡単に作ることができます。
開発環境
Next.js
+ TypeScript
+ MUI
+ React Hook Form
を利用します。
Next.jsのプロジェクト作成
npx create-next-app@latest
MUIのインストール
npm install @mui/material @emotion/react @emotion/styled
React Hook Formのインストール
npm install react-hook-form
useFieldArrayの基本的な使い方
まずはフォームの追加、削除、リセットをできるようにします。
- useForm
- React Hook Formの基本的な設定を行い、フォームデータ取得やフォームのリセットに利用する関数を呼び出しています。
- useFieldArray
- 動的にフォームを作成するための設定と関数の呼び出しをしています。
- fields
- フォームに関する状態の情報が含まれています。
- append
- fieldsの最後にフォームを1つ追加することができます、その際に値も設定することができます。
- remove
- フォームのインデックスを指定してフォームを1つ削除します。
src/app/page.tsx
"use client";
import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";
export default function Home() {
// フォームの型定義
type FormValues = {
profile: {
firstName: string;
lastName: string;
}[];
};
// デフォルトの値
const defaultValue = { firstName: "", lastName: "" };
// React Hook Form の設定
const { control, handleSubmit, reset } = useForm<FormValues>({
mode: "onTouched",
defaultValues: {
profile: [defaultValue],
},
});
// useFieldArrayの設定と関数の呼び出し
const { fields, append, remove } = useFieldArray({
control: control,
name: "profile",
});
// フォームの送信処理
const onSubmit = async (data: FormValues) => {
console.log(data.profile);
};
return (
<main className={styles.main}>
<Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
{/* 動的に追加される入力フォーム */}
{fields.map((field, index) => {
return (
<Stack key={field.id} direction={"row"} spacing={1} my={1}>
{/* 名前の入力フォーム */}
<Controller
name={`profile.${index}.firstName`}
control={control}
render={({ field }) => (
<TextField {...field} placeholder={"名前"} />
)}
/>
{/* 名字の入力フォーム */}
<Controller
name={`profile.${index}.lastName`}
control={control}
render={({ field }) => (
<TextField {...field} placeholder={"名字"} />
)}
/>
{/* フォームの削除ボタン */}
<Button variant="outlined" onClick={() => remove(index)}>
削除
</Button>
</Stack>
);
})}
<Stack spacing={1}>
{/* フォーム追加ボタン */}
<Button
variant="contained"
fullWidth
onClick={() => append(defaultValue)}
>
追加
</Button>
{/* フォーム送信ボタン */}
<Button
variant="contained"
fullWidth
onClick={handleSubmit(onSubmit)}
>
送信
</Button>
{/* フォームリセットボタン */}
<Button variant="outlined" fullWidth onClick={() => reset()}>
リセット
</Button>
</Stack>
</Box>
</main>
);
}
順番を指定してフォームを追加
これまでは末尾にフォームを追加していましたが、ここでは追加ボタンのひとつ下に追加できるようにします。
- insertを利用することで特定の位置にフォームを挿入することができます。
insert(index: number, value: any)
- index:挿入する位置のindex
- value:挿入する要素の値
src/app/page.tsx
"use client";
import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";
export default function Home() {
// フォームの型定義
type FormValues = {
profile: {
firstName: string;
lastName: string;
}[];
};
// デフォルトの値
const defaultValue = { firstName: "", lastName: "" };
// React Hook Form の設定
const { control, handleSubmit, reset } = useForm<FormValues>({
mode: "onTouched",
defaultValues: {
profile: [defaultValue],
},
});
// useFieldArrayの設定と関数の呼び出し
const { fields, insert, remove } = useFieldArray({
control: control,
name: "profile",
});
// フォームの送信処理
const onSubmit = async (data: FormValues) => {
console.log(data.profile);
};
return (
<main className={styles.main}>
<Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
{/* 動的に追加される入力フォーム */}
{fields.map((field, index) => {
return (
<Stack key={field.id} direction={"row"} spacing={1} my={1}>
{/* 名前の入力フォーム */}
<Controller
name={`profile.${index}.firstName`}
control={control}
render={({ field }) => (
<TextField {...field} placeholder={"名前"} />
)}
/>
{/* 名字の入力フォーム */}
<Controller
name={`profile.${index}.lastName`}
control={control}
render={({ field }) => (
<TextField {...field} placeholder={"名字"} />
)}
/>
{/* フォームの位置指定して追加ボタン */}
<Button
variant="contained"
onClick={() => insert(index + 1, defaultValue)}
>
追加
</Button>
{/* フォームの削除ボタン */}
<Button variant="outlined" onClick={() => remove(index)}>
削除
</Button>
</Stack>
);
})}
<Stack spacing={1}>
{/* フォーム送信ボタン */}
<Button
variant="contained"
fullWidth
onClick={handleSubmit(onSubmit)}
>
送信
</Button>
{/* フォームリセットボタン */}
<Button variant="outlined" fullWidth onClick={() => reset()}>
リセット
</Button>
</Stack>
</Box>
</main>
);
}
フォームの並び替え
フォームを「一つ上」と「一つ下」に移動できるようにします。
- moveを利用することでフォームを指定した位置に移動することができます。
move(from: number, to: number)
- from:移動させたいフォームの現在の位置を示すindex
- to:移動後のフォームの位置を示すindex
src/app/page.tsx
"use client";
import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";
export default function Home() {
// フォームの型定義
type FormValues = {
profile: {
firstName: string;
lastName: string;
}[];
};
// デフォルトの値
const defaultValue = { firstName: "", lastName: "" };
// React Hook Form の設定
const { control, handleSubmit, reset } = useForm<FormValues>({
mode: "onTouched",
defaultValues: {
profile: [defaultValue],
},
});
// useFieldArrayの設定と関数の呼び出し
const { fields, insert, remove, move } = useFieldArray({
control: control,
name: "profile",
});
// フォームの送信処理
const onSubmit = async (data: FormValues) => {
console.log(data.profile);
};
return (
<main className={styles.main}>
<Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
{/* 動的に追加される入力フォーム */}
{fields.map((field, index) => {
return (
<Stack key={field.id} direction={"row"} spacing={1} my={1}>
{/* 名前の入力フォーム */}
<Controller
name={`profile.${index}.firstName`}
control={control}
render={({ field }) => (
<TextField {...field} placeholder={"名前"} />
)}
/>
{/* 名字の入力フォーム */}
<Controller
name={`profile.${index}.lastName`}
control={control}
render={({ field }) => (
<TextField {...field} placeholder={"名字"} />
)}
/>
{/* フォームの位置指定して追加ボタン */}
<Button
variant="contained"
onClick={() => insert(index + 1, defaultValue)}
>
追加
</Button>
{/* フォームの削除ボタン */}
<Button variant="outlined" onClick={() => remove(index)}>
削除
</Button>
{/* 一つ下に移動 */}
<Button
variant="contained"
onClick={() => move(index, index + 1)}
>
↓
</Button>
{/* 一つ上に移動 */}
<Button
variant="contained"
onClick={() => move(index, index - 1)}
>
↑
</Button>
</Stack>
);
})}
<Stack spacing={1}>
{/* フォーム送信ボタン */}
<Button
variant="contained"
fullWidth
onClick={handleSubmit(onSubmit)}
>
送信
</Button>
{/* フォームリセットボタン */}
<Button variant="outlined" fullWidth onClick={() => reset()}>
リセット
</Button>
</Stack>
</Box>
</main>
);
}
バリデーション
入力必須と最大文字数のバリデーションを実装します。
- validationRulesにバリデーションルールを定義
- fieldState.invalidでバリデーションエラーの有無をチェック
- fieldState.error?.messageでバリデーションエラーメッセージを表示
src/app/page.tsx
"use client";
import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";
export default function Home() {
// フォームの型定義
type FormValues = {
profile: {
firstName: string;
lastName: string;
}[];
};
// デフォルトの値
const defaultValue = { firstName: "", lastName: "" };
// React Hook Form の設定
const { control, handleSubmit, reset } = useForm<FormValues>({
mode: "onTouched",
defaultValues: {
profile: [defaultValue],
},
});
// useFieldArrayの設定と関数の呼び出し
const { fields, insert, remove, move } = useFieldArray({
control: control,
name: "profile",
});
// フォームの送信処理
const onSubmit = async (data: FormValues) => {
console.log(data.profile);
};
// バリデーションルール
const validationRules = {
lastName: {
required: "名字を入力してください",
maxLength: {
value: 20,
message: "最大20文字",
},
},
firstName: {
required: "名前を入力してください",
maxLength: {
value: 20,
message: "最大20文字",
},
},
};
return (
<main className={styles.main}>
<Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
{/* 動的に追加される入力フォーム */}
{fields.map((field, index) => {
return (
<Stack key={field.id} direction={"row"} spacing={1} my={1}>
{/* 名前の入力フォーム */}
<Controller
name={`profile.${index}.firstName`}
control={control}
rules={validationRules.firstName}
render={({ field, fieldState }) => (
<TextField
{...field}
placeholder={"名前"}
// バリデーションエラーの場合はフォームをエラー状態にする
error={fieldState.invalid}
// エラーメッセージを表示
helperText={fieldState.error?.message}
/>
)}
/>
{/* 名字の入力フォーム */}
<Controller
name={`profile.${index}.lastName`}
control={control}
rules={validationRules.lastName}
render={({ field, fieldState }) => (
<TextField
{...field}
placeholder={"名字"}
// バリデーションエラーの場合はフォームをエラー状態にする
error={fieldState.invalid}
// エラーメッセージを表示
helperText={fieldState.error?.message}
/>
)}
/>
{/* フォームの位置指定して追加ボタン */}
<Button
variant="contained"
onClick={() => insert(index + 1, defaultValue)}
>
追加
</Button>
{/* フォームの削除ボタン */}
<Button variant="outlined" onClick={() => remove(index)}>
削除
</Button>
{/* 一つ下に移動 */}
<Button
variant="contained"
onClick={() => move(index, index + 1)}
>
↓
</Button>
{/* 一つ上に移動 */}
<Button
variant="contained"
onClick={() => move(index, index - 1)}
>
↑
</Button>
</Stack>
);
})}
<Stack spacing={1}>
{/* フォーム送信ボタン */}
<Button
variant="contained"
fullWidth
onClick={handleSubmit(onSubmit)}
>
送信
</Button>
{/* フォームリセットボタン */}
<Button variant="outlined" fullWidth onClick={() => reset()}>
リセット
</Button>
</Stack>
</Box>
</main>
);
}
整える
ユーザーが利用しやすいように下記の様な細かな調整を行います。
- 最後の一つのフォームは削除できないように
- 最大件数の制限を設ける
- 件数を表示
- 一番上または下のフォームはそれ以上は移動できないように
- バリデーションエラーがある場合は送信できないように
src/app/page.tsx
"use client";
import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";
export default function Home() {
// フォームの型定義
type FormValues = {
profile: {
firstName: string;
lastName: string;
}[];
};
// デフォルトの値
const defaultValue = { firstName: "", lastName: "" };
// React Hook Form の設定
const {
control,
handleSubmit,
reset,
formState: { isValid },
} = useForm<FormValues>({
mode: "onTouched",
defaultValues: {
profile: [defaultValue],
},
});
// useFieldArrayの設定と関数の呼び出し
const { fields, insert, remove, move } = useFieldArray({
control: control,
name: "profile",
});
// フォームの送信処理
const onSubmit = async (data: FormValues) => {
console.log(data.profile);
};
// バリデーションルール
const validationRules = {
lastName: {
required: "名字を入力してください",
maxLength: {
value: 20,
message: "最大20文字",
},
},
firstName: {
required: "名前を入力してください",
maxLength: {
value: 20,
message: "最大20文字",
},
},
};
// 最大件数
const MAXIMUM_USER = 5;
return (
<main className={styles.main}>
<Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
{/* 動的に追加される入力フォーム */}
{fields.map((field, index) => {
return (
<Stack key={field.id} direction={"row"} spacing={1} my={1}>
{/* 名前の入力フォーム */}
<Controller
name={`profile.${index}.firstName`}
control={control}
rules={validationRules.firstName}
render={({ field, fieldState }) => (
<TextField
{...field}
placeholder={"名前"}
// バリデーションエラーの場合はフォームをエラー状態にする
error={fieldState.invalid}
// エラーメッセージを表示
helperText={fieldState.error?.message}
/>
)}
/>
{/* 名字の入力フォーム */}
<Controller
name={`profile.${index}.lastName`}
control={control}
rules={validationRules.lastName}
render={({ field, fieldState }) => (
<TextField
{...field}
placeholder={"名字"}
// バリデーションエラーの場合はフォームをエラー状態にする
error={fieldState.invalid}
// エラーメッセージを表示
helperText={fieldState.error?.message}
/>
)}
/>
{/* フォームの位置指定して追加ボタン */}
<Button
variant="contained"
onClick={() => insert(index + 1, defaultValue)}
disabled={fields.length >= MAXIMUM_USER}
>
追加
</Button>
{/* フォームの削除ボタン */}
<Button
variant="outlined"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
削除
</Button>
{/* 一つ下に移動 */}
<Button
variant="contained"
onClick={() => move(index, index + 1)}
disabled={fields.length <= index + 1}
>
↓
</Button>
{/* 一つ上に移動 */}
<Button
variant="contained"
onClick={() => move(index, index - 1)}
disabled={!index}
>
↑
</Button>
</Stack>
);
})}
<Stack spacing={1}>
{/* フォーム送信ボタン */}
<Button
variant="contained"
fullWidth
onClick={handleSubmit(onSubmit)}
disabled={!isValid}
>
送信({fields.length}件)
</Button>
{/* フォームリセットボタン */}
<Button variant="outlined" fullWidth onClick={() => reset()}>
リセット
</Button>
</Stack>
</Box>
</main>
);
}
おわりに
この様に動的な入力フォームを作成する際はReact Hook FormのuseFieldArrayを利用することで簡単に実装することができるのでおすすめです。